Repository navigation
fix(plugin-auth)!: under local sign-in off, the last identity provider is the last way in; the direct sign-in door keeps ?redirect= - #33623
Conversation
…nly when the sign-in page offers it Claude-Session: https://claude.ai/code/session_016WV4d1NJYFGVi2FRztyBNo Co-authored-by: Claude <noreply@anthropic.com>
… off, with controls Claude-Session: https://claude.ai/code/session_016WV4d1NJYFGVi2FRztyBNo Co-authored-by: Claude <noreply@anthropic.com>
…-in door's error landings Claude-Session: https://claude.ai/code/session_016WV4d1NJYFGVi2FRztyBNo Co-authored-by: Claude <noreply@anthropic.com>
…ct; changeset Claude-Session: https://claude.ai/code/session_016WV4d1NJYFGVi2FRztyBNo Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9f8e2fc6d8ac8c79bb110b727cc583c1a5ee23cb && git checkout 9f8e2fc6d8ac8c79bb110b727cc583c1a5ee23cb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4c32b9694b7b248646b2053b6eafea2cc212693b 4cf17caabd606319a63ffc071e6061f75361156f && git checkout -B drift-repro 4c32b9694b7b248646b2053b6eafea2cc212693b && git merge --no-ff 4cf17caabd606319a63ffc071e6061f75361156f
node scripts/docs-audit/affected-docs.mjs --json 4c32b9694b7b248646b2053b6eafea2cc212693b
|
|
domain:server#5 · os-elon-musk · session_016WV4d1NJYFGVi2FRztyBNo Request: review · area A1 · Part of #33358 (items 1, 3, 4) · head 2a03b65 · requested at 2026-11-15T03:15Z Local (os-dev report, #33358 comment 6888421655): plugin-auth full suite 274 files / 5312 passed, 10 skipped; build + typecheck exit 0; eslint exit 0; four ablations red (delete door 2/38, boot report 2/38, errorCallbackURL 1/87, rate-limited landing 1/87); dogfood battery at 2a03b65: 467 files / 3950 passed, 0 failed; derived gates 97: 94 exit 0, 3 NOT MEASURED ( |
|
Contract-review-claim: session_016WV4d1PGptBW3QRQNmy5e6 · head 2a03b65 · 2026-11-15T03:17Z review#3. |
Contract reviewServed-tier: Read: the PR's four commits on top of ① Derived judgmentsItems 3 and 4: the carried redirect uses the door's own return-path rule. I found no open redirect.
Item 1: the floor counts what the page draws.
must-fix M1: under
② Semver level
③ Boundary flags
Nits (none blocks).
Implemented-by: VERDICT: FAIL |
… boot report give the local-path remedy whatever email and password says Review#3 M1 and N1-N4: the copy and the report branch are chosen by pageOffersLocalSignIn, the off copy names email and password on with the local path back to hidden, the foreign-redirect pin loops over every foreign spelling, and the changeset and the docs say what changed. Claude-Session: https://claude.ai/code/session_016WV4d1NJYFGVi2FRztyBNo Co-authored-by: Claude <noreply@anthropic.com>
|
Contract-review-claim: session_016WV4d1PGptBW3QRQNmy5e6 · head cc741c8 · 2026-11-15T03:35Z review#3 (delta). |
Contract reviewServed-tier: This reviews the delta ① Derived judgmentsM1: closed.
No refuse/allow decision changed. In both Redirect: still safe. N1–N4: addressed.
Nits (none blocks).
② Semver levelUnchanged from my earlier record. ③ Boundary flags
④ Design judgmentThe delta makes the requested fix with the predicate the floor already uses, so the remedy is chosen by the rule the page uses to draw local sign-in. One copy key with a two-part remedy is enough. A third variant would add a locale set without telling the reader anything more. The boot report names the second setting only when it is needed, which keeps the common case short. The CONTROL under Implemented-by: VERDICT: PASS |
…s/permissions/sso.mdx) Claude-Session: https://claude.ai/code/session_016WV4d1NJYFGVi2FRztyBNo Co-authored-by: Claude <noreply@anthropic.com>
Contract review — PASS carriesServed-tier: This carries my PASS at ① Derived judgments
② Semver levelUnchanged. The changeset is byte-identical: ③ Boundary flagsUnchanged from 6888701895. Items 2 and 5 and the settings save's floor predicate are still open on the card, outside this PR. ④ Design judgmentUnchanged from 6888701895, since no reviewed line moved. Nits N5 and N6 still stand, and neither blocks. CI at this head when I posted: 24 passed, 15 skipped, 12 in progress, none failed. Implemented-by: VERDICT: PASS |
|
domain:server#5 · os-elon-musk · session_016WV4d1NJYFGVi2FRztyBNo ACCEPT · area A1 · Part of #33358 (items 1, 3, 4) · head 4cf17ca · 2026-11-15T04:22Z
|
|
domain:server#5 · os-elon-musk · session_016WV4cytdN5jRpgKX58AY4d ACCEPT · area A1 · Part of #33358 (items 1 (the floor), 3, 4) · head 4cf17ca · 2026-11-15T04:23Z
|
Part of #33358
Clause-②: yes (narrowing) — narrowed: with direct sign-in on and its local sign-in path
off(directSignIn.localSignIn: 'off', orOS_AUTH_DIRECT_SIGN_IN_LOCAL_PATH=off),POST /api/v1/auth/sso/delete-provideron the last identity provider answers409 DELETE_RESTRICTEDand deletes nothing while email and password is switched on, where it deleted the row; email and password, and phone sign-in, no longer count as a way in there, because the sign-in page does not offer them. Widened: module exports (not on the package entry)pageOffersLocalSignIn,pageOffersEmailPasswordandcarriedRedirect, an optionallocalSignInonSignInMethodConfigView.features, and anasThePageOffersoption onotherSignInMethods; the refusal underoffanswers with a new message, the copy keyenterpriseSsoLocalOff, in all 11 locales.Metadata-protocol: none
Skills-docs: none — no skill under
skills/**teaches direct sign-in, the provider delete or the boot report;content/docs/permissions/sso.mdxgains the rule in this change.维护者速读
off时,登录页不再显示邮箱/密码和手机登录;现在"保留最后一种登录方式"的检查也不再把它们算作登录方式,所以删除最后一个身份提供方会被拒绝(409),启动时也会报错提示。另外,直接登录入口把人送回登录页时(身份提供方报错、被限流、无法发起)会带上原来的?redirect=。off下删掉最后一个身份提供方会成功,随后登录页只显示"没有可用的登录方式",所有人(包括管理员)被锁在外面;深链接用户在出错后重试会落到首页而不是原来要去的页面。off的自托管部署(之前允许的删除现在被拒,属于收窄,已标 BREAKING);hidden(默认)和未开启直接登录的部署行为不变。回滚即 revert 本 PR。packages/plugins/plugin-auth/src/sign-in-method-floor.ts(ssoProviderDeleteRefusal与resolveNoSignInMethodReport两处判断)、packages/plugins/plugin-auth/src/direct-sign-in.ts(carriedRedirect与两个落地地址)、.changeset/33358-local-sign-in-off-floor.md。pnpm --filter @objectstack/plugin-auth exec vitest run src/sign-in-method-floor.test.ts src/direct-sign-in.test.ts,预期全部通过,其中包含under the local sign-in path off, deleting the last provider is refused与keeps the page's ?redirect=几条。What changes
Items covered from #33358: 1 (N7, the sign-in method floor and the boot report), 3 (the provider-error return address keeps
?redirect=) and 4 (the rate-limited landing keeps?redirect=). Items 2 (the IP allow-list landing on the direct sign-in door) and 5 (the N13 sign-out note) remain on the card for a later round.Item 1 — the floor counts what the sign-in page offers. The page decides what it draws from
features.localSignIn, whichgetPublicConfig()serves only while direct sign-in is on: under'off'LoginFormdraws no email and password and no phone sign-in. The floor now reads the same served value with the same predicate (pageOffersLocalSignIn(config)isconfig.features?.localSignIn !== 'off', exactly the page's test), so:ssoProviderDeleteRefusal(the/sso/delete-providerdoor) andresolveNoSignInMethodReport(the boot report) count email and password only when it is on and the page offers it (pageOffersEmailPassword), and count phone sign-in only when the page offers it (otherSignInMethods(..., { asThePageOffers: true })).offcarries a new localized copy,enterpriseSsoLocalOff(11 locales), chosen by!pageOffersLocalSignIn(config)whatever email and password says, because the existing copy's remedy, "turn email and password sign-in back on", draws nothing underoff. Its remedy: register another identity provider, set up another sign-in method, or set the local sign-in path back tohiddenwith email and password sign-in on.off(chosen the same way) namesOS_AUTH_DIRECT_SIGN_IN_LOCAL_PATH=hiddenas the recovery, plusOS_AUTH_EMAIL_PASSWORD_ENABLED=truewhen email and password is off too; it no longer says "no phone-number sign-in" there.Phone sign-in is included as a bounded in-place fix: it is the same defect class (a local way in the page does not draw under
off, counted anyway), a mechanical change in the same file, held by this claim, in the same gate family. Without it a deployment with phone sign-in on andoffwould pass the same delete and reach the same "No sign-in method is available" page.Items 3 and 4 — the door's landings keep
?redirect=. The Console hands the door its own path and query ascallbackURL(for example/_console/login?redirect=%2Fapps%2Fcrm&direct=returned).carriedRedirect(kept)readsredirectfrom the already-kept return path, carries it only when it is sent once and is itself a kept return path (keptReturnPath, the door's existing same-origin rule), and both landings now use it: theerrorCallbackURLhanded to the vendor (/login?redirect=...; better-auth appends itserrorto it) and the door's own landing (/login?error=...&redirect=..., the rate-limited one and the unavailable one alike). With no?redirect=, or a foreign one, both are the bare page exactly as before.Docs:
content/docs/permissions/sso.mdxsays both rules, names the two checks this PR changes (the provider delete and the boot report, email and password and phone sign-in alike), and says the settings save is still open on #33358.Tests
src/sign-in-method-floor.test.ts, the composition (real kernel, sqlite, AuthPlugin and settings): underdirectSignIn: { localSignIn: 'off' }with email and password on, deleting the last provider answers 409DELETE_RESTRICTEDwithenterpriseSsoLocalOffand the row stays; CONTROL: underhiddenthe same delete is 200 and the row is gone; a boot underoffwith no provider reportsno_sign_in_method_at_bootnamingOS_AUTH_DIRECT_SIGN_IN_LOCAL_PATH=hidden; CONTROL: the same boot underhiddensays nothing.off, neither email and password nor phone sign-in counts (delete refused, boot report said); CONTROL underhidden: both count as before; phone alone underoffwith email and password off is refused withenterpriseSsoLocalOffand its boot report names both settings without "no phone-number sign-in"; CONTROL: email and password off underhiddenkeeps theenterpriseSsocopy and theOS_AUTH_EMAIL_PASSWORD_ENABLED=truereport. Composition: a boot underoffwith email and password off namesOS_AUTH_DIRECT_SIGN_IN_LOCAL_PATH=hidden and OS_AUTH_EMAIL_PASSWORD_ENABLED=true.src/direct-sign-in.test.ts: the vendor'serrorCallbackURLkeeps?redirect=; the rate-limited landing keeps it; CONTROL: with no?redirect=both landings are the bare page, as before; a foreign?redirect=is dropped and the landings are the bare page, pinned over every spelling inFOREIGN_RETURN_PATHS(24: absolute, protocol-relative, backslash,javascript:, control characters, percent-encoded forms,/..//, empty, over-long).Acceptance notes
OS_AUTH_LOCAL_SIGN_IN=off; the variable in the code isOS_AUTH_DIRECT_SIGN_IN_LOCAL_PATH(directSignIn.localSignIn). The premise holds otherwise.signInMethodFloorPredicate) still counts email and password on underoff, so underoffa save that switches off Google as the last social provider passes. Its refusal copy speaks of turning email and password back on, so a fix there needs its own copy. Reported to the seat for the card rather than widened here.enterpriseSsoUnverified) still says "with email and password off"; underoffwith email and password on it is slightly inaccurate, but it is still the right refusal.Verification
Review#3 delta: head
cc741c8ef3answers M1 and N1 to N4 of contract record 6888499485; the runs below the dogfood paragraph were taken at2a03b651b6and the delta's runs are in the first fold. Earlier head2a03b651b6(merge basec32bb6ccb=origin/mainat the time;origin/mainhad not moved when re-fetched before opening).Dogfood battery (the title carries
!):pnpm --filter @objectstack/dogfood testunder the verify lock, dependency closure built first, on head2a03b651b6. It was still running when this PR opened; its verdict (Test Files, Tests, every failure named) goes into theos-dev-reportcomment on #33358, and the PR's ownDogfood Regression Gateruns it in the queue.Review#3 delta, local runs on head cc741c8
pnpm --filter @objectstack/plugin-auth test(verify lock):Test Files 274 passed (274),Tests 5336 passed | 10 skipped (5346),VERDICT command-exit 0.pnpm --filter @objectstack/plugin-auth typecheck:VERDICT command-exit 0,check:test-typecheck: OK(ledger unchanged). eslint--no-inline-configon the three changed.tsfiles: exit 0.check-nul-bytes: OK.check-adr-0087-registration,check-empty-changeset,check-changeset-no-major,check:doc-authoring,check:issue-citations,check-closing-keyword-parity,check:doc-anchors,check-affected-docs: all exit 0. The full derived set was not re-run for the delta; CI runs it.2a03b651b6, where the battery was green.Local runs on head 2a03b65
pnpm --filter @objectstack/plugin-auth test(verify lock):Test Files 274 passed (274),Tests 5312 passed | 10 skipped (5322),VERDICT command-exit 0.pnpm --filter @objectstack/plugin-auth build && pnpm --filter @objectstack/plugin-auth typecheck:VERDICT command-exit 0;check:test-typecheck: OK(10 files / 72 errors / 23 pinned signatures held in the existing ledger, unchanged).eslint --no-inline-configon the four changed source files: exit 0.node scripts/check-nul-bytes.mjs: OK.src/sign-in-method-floor.test.ts40/40,src/direct-sign-in.test.ts87/87.Ablations (one per fix; each mutation landed on disk via scripts/ablation-replace.mjs and was restored, blob equal to HEAD and git diff HEAD empty)
config.emailPassword?.enabled !== false: 2 failed / 38 (the composition delete underoff, the judge case).config.emailPassword?.enabled !== false: 2 failed / 38 (the composition boot underoff, the judge case).errorCallbackURLback to the bare/login: 1 failed / 87 (keeps the page's?redirect=).The tests import
src/directly (nodist/in the path), so no rebuild leg was needed.Derived gate families (dispatch-gates --commands, 97 families)
97 run: 94 exit 0; 3 exit 3
PREREQUISITE NOT MET, read as NOT MEASURED and left to CI:check:skill-examples(nopackages/client-react/distin this worktree),check:dual-build-cjs-loads(several packages unbuilt),check:type-check-debt(its re-measure prerequisite).dispatch-gates --ran: 97 derived, 97 run, 0 UNRUN.Generated by Claude Code